{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1633a440",
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\"\n",
    "Week 2 Assignment: LLM Engineering\n",
    "Author: Nikhil Raut\n",
    "\n",
    "Notebook: ai_domain_finder.ipynb\n",
    "\n",
    "Purpose:\n",
    "Build an agentic AI Domain Finder that proposes short, brandable .com names, verifies availability via RDAP, \n",
    "then returns: \n",
    "    a list of available .coms, \n",
    "    one preferred pick, \n",
    "    and a brief audio rationale.\n",
    "\"\"\"\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "da528fbe",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "import json\n",
    "import requests\n",
    "from typing import Dict, List, Tuple, Any, Optional\n",
    "import re\n",
    "\n",
    "from dotenv import load_dotenv\n",
    "from openai import OpenAI\n",
    "import gradio as gr\n",
    "\n",
    "load_dotenv(override=True)\n",
    "\n",
    "OPENAI_MODEL = \"gpt-5-nano-2025-08-07\"\n",
    "TTS_MODEL = \"gpt-4o-mini-tts\"\n",
    "\n",
    "openai = OpenAI()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "361f7fe3",
   "metadata": {},
   "outputs": [],
   "source": [
    "# --- robust logging that works inside VS Code notebooks + Gradio threads ---\n",
    "import sys, logging, threading\n",
    "from collections import deque\n",
    "from typing import Any\n",
    "\n",
    "DEBUG_LLM = True          # toggle on/off noisy logs\n",
    "CLEAR_LOG_ON_RUN = True   # clear panel before each submit\n",
    "\n",
    "_LOG_BUFFER = deque(maxlen=2000)   # keep ~2000 lines in memory\n",
    "_LOG_LOCK = threading.Lock()\n",
    "\n",
    "class GradioBufferHandler(logging.Handler):\n",
    "    def emit(self, record: logging.LogRecord) -> None:\n",
    "        try:\n",
    "            msg = self.format(record)\n",
    "        except Exception:\n",
    "            msg = record.getMessage()\n",
    "        with _LOG_LOCK:\n",
    "            for line in (msg.splitlines() or [\"\"]):\n",
    "                _LOG_BUFFER.append(line)\n",
    "\n",
    "def get_log_text() -> str:\n",
    "    with _LOG_LOCK:\n",
    "        return \"\\n\".join(_LOG_BUFFER)\n",
    "\n",
    "def clear_log_buffer() -> None:\n",
    "    with _LOG_LOCK:\n",
    "        _LOG_BUFFER.clear()\n",
    "\n",
    "def _setup_logger() -> logging.Logger:\n",
    "    logger = logging.getLogger(\"aidf\")\n",
    "    logger.setLevel(logging.DEBUG if DEBUG_LLM else logging.INFO)\n",
    "    logger.handlers.clear()\n",
    "    fmt = logging.Formatter(\"%(asctime)s | %(levelname)s | %(message)s\", \"%H:%M:%S\")\n",
    "\n",
    "    stream = logging.StreamHandler(stream=sys.stdout)  # captured by VS Code notebook\n",
    "    stream.setFormatter(fmt)\n",
    "\n",
    "    buf = GradioBufferHandler()                        # shown inside the Gradio panel\n",
    "    buf.setFormatter(fmt)\n",
    "\n",
    "    logger.addHandler(stream)\n",
    "    logger.addHandler(buf)\n",
    "    logger.propagate = False\n",
    "    return logger\n",
    "\n",
    "logger = _setup_logger()\n",
    "\n",
    "def dbg_json(obj: Any, title: str = \"\") -> None:\n",
    "    \"\"\"Convenience: pretty-print JSON-ish objects to the logger.\"\"\"\n",
    "    try:\n",
    "        txt = json.dumps(obj, ensure_ascii=False, indent=2)\n",
    "    except Exception:\n",
    "        txt = str(obj)\n",
    "    if title:\n",
    "        logger.debug(\"%s\\n%s\", title, txt)\n",
    "    else:\n",
    "        logger.debug(\"%s\", txt)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "519674b2",
   "metadata": {},
   "outputs": [],
   "source": [
    "RDAP_URL = \"https://rdap.verisign.com/com/v1/domain/{}\"\n",
    "\n",
    "_ALPHA_RE = re.compile(r\"^[a-z]+$\", re.IGNORECASE)\n",
    "\n",
    "def _to_com(domain: str) -> str:\n",
    "    d = domain.strip().lower()\n",
    "    return d if d.endswith(\".com\") else f\"{d}.com\"\n",
    "\n",
    "def _sld_is_english_alpha(fqdn: str) -> bool:\n",
    "    \"\"\"\n",
    "    True only if the second-level label (just before .com) is made up\n",
    "    exclusively of English letters (a-z).\n",
    "    Examples:\n",
    "      foo.com      -> True\n",
    "      foo-bar.com  -> False\n",
    "      foo1.com     -> False\n",
    "      café.com     -> False\n",
    "      xn--cafe.com -> False\n",
    "      www.foo.com  -> True (checks 'foo')\n",
    "    \"\"\"\n",
    "    if not fqdn.endswith(\".com\"):\n",
    "        return False\n",
    "    sld = fqdn[:-4].split(\".\")[-1]  # take label immediately before .com\n",
    "    return bool(sld) and bool(_ALPHA_RE.fullmatch(sld))\n",
    "\n",
    "def check_com_availability(domain: str) -> Dict:\n",
    "    fqdn = _to_com(domain)\n",
    "    # Skip API if not strictly English letters\n",
    "    if not _sld_is_english_alpha(fqdn):\n",
    "        return {\"domain\": fqdn, \"available\": False, \"status\": 0}\n",
    "\n",
    "    try:\n",
    "        r = requests.get(RDAP_URL.format(fqdn), timeout=6)\n",
    "        return {\"domain\": fqdn, \"available\": (r.status_code == 404), \"status\": r.status_code}\n",
    "    except requests.RequestException:\n",
    "        return {\"domain\": fqdn, \"available\": False, \"status\": 0}\n",
    "\n",
    "def check_com_availability_bulk(domains: List[str]) -> Dict:\n",
    "    \"\"\"\n",
    "    Input: list of domain roots or FQDNs.\n",
    "    Returns:\n",
    "      {\n",
    "        \"results\": [{\"domain\": \"...\", \"available\": bool, \"status\": int}, ...],\n",
    "        \"available\": [\"...\"],                 # convenience\n",
    "        \"count_available\": int\n",
    "      }\n",
    "    \"\"\"\n",
    "    session = requests.Session()\n",
    "    results: List[Dict] = []\n",
    "\n",
    "    for d in domains:\n",
    "        fqdn = _to_com(d)\n",
    "\n",
    "        # Skip API if not strictly English letters\n",
    "        if not _sld_is_english_alpha(fqdn):\n",
    "            results.append({\"domain\": fqdn, \"available\": False, \"status\": 0})\n",
    "            continue\n",
    "\n",
    "        try:\n",
    "            r = session.get(RDAP_URL.format(fqdn), timeout=6)\n",
    "            ok = (r.status_code == 404)\n",
    "            results.append({\"domain\": fqdn, \"available\": ok, \"status\": r.status_code})\n",
    "        except requests.RequestException:\n",
    "            results.append({\"domain\": fqdn, \"available\": False, \"status\": 0})\n",
    "\n",
    "    available = [x[\"domain\"] for x in results if x[\"available\"]]\n",
    "    return {\"results\": results, \"available\": available, \"count_available\": len(available)}\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cd20c262",
   "metadata": {},
   "outputs": [],
   "source": [
    "check_tool_bulk = {\n",
    "    \"type\": \"function\",\n",
    "    \"function\": {\n",
    "        \"name\": \"check_com_availability_bulk\",\n",
    "        \"description\": \"Batch check .com availability via RDAP for a list of domains (roots or FQDNs).\",\n",
    "        \"parameters\": {\n",
    "            \"type\": \"object\",\n",
    "            \"properties\": {\n",
    "                \"domains\": {\n",
    "                    \"type\": \"array\",\n",
    "                    \"items\": {\"type\": \"string\"},\n",
    "                    \"minItems\": 1,\n",
    "                    \"maxItems\": 50,\n",
    "                    \"description\": \"List of domain roots or .com FQDNs.\"\n",
    "                }\n",
    "            },\n",
    "            \"required\": [\"domains\"],\n",
    "            \"additionalProperties\": False\n",
    "        }\n",
    "    }\n",
    "}\n",
    "\n",
    "TOOLS = [check_tool_bulk]\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2a9138b6",
   "metadata": {},
   "outputs": [],
   "source": [
    "def handle_tool_calls(message) -> List[Dict]:\n",
    "    results = []\n",
    "    for call in (message.tool_calls or []):\n",
    "        fn = getattr(call.function, \"name\", None)\n",
    "        args_raw = getattr(call.function, \"arguments\", \"\") or \"{}\"\n",
    "        try:\n",
    "            args = json.loads(args_raw)\n",
    "        except Exception:\n",
    "            args = {}\n",
    "\n",
    "        logger.debug(\"TOOL CALL -> %s | args=%s\", fn, json.dumps(args, ensure_ascii=False))\n",
    "\n",
    "        if fn == \"check_com_availability_bulk\":\n",
    "            payload = check_com_availability_bulk(args.get(\"domains\", []))\n",
    "        elif fn == \"check_com_availability\":\n",
    "            payload = check_com_availability(args.get(\"domain\", \"\"))\n",
    "        else:\n",
    "            payload = {\"error\": f\"unknown tool {fn}\"}\n",
    "\n",
    "        logger.debug(\"TOOL RESULT <- %s | %s\", fn, json.dumps(payload, ensure_ascii=False))\n",
    "\n",
    "        results.append({\n",
    "            \"role\": \"tool\",\n",
    "            \"tool_call_id\": call.id,\n",
    "            \"content\": json.dumps(payload),\n",
    "        })\n",
    "    return results\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0b80c860",
   "metadata": {},
   "outputs": [],
   "source": [
    "SYSTEM_PROMPT = \"\"\"You are the Agent for project \"AI Domain Finder\".\n",
    "Goal: suggest .com domains and verify availability using the tool ONLY (no guessing).\n",
    "\n",
    "Do this each interaction:\n",
    "- Generate up to ~20 short, brandable .com candidates from:\n",
    "  (1) Industry, (2) Target Customers, (3) Description.\n",
    "- Use the BULK tool `check_com_availability_bulk` with a list of candidates\n",
    "  (roots or FQDNs). Prefer a single call or very few batched calls.\n",
    "- If >= 5 available .coms are found, STOP checking and finalize the answer.\n",
    "\n",
    "Output Markdown with EXACT section headings:\n",
    "1) Available .com domains:\n",
    "   - itemized list of available .coms only (root + .com)\n",
    "2) Preferred domain:\n",
    "   - a single best pick\n",
    "3) Audio explanation:\n",
    "   - 1–2 concise sentences explaining the preference\n",
    "\n",
    "Constraints:\n",
    "- Use customer-familiar words where helpful.\n",
    "- Keep names short, simple, pronounceable; avoid hyphens/numbers unless meaningful.\n",
    "- Never include TLDs other than .com.\n",
    "- domain is made up of english alphabets in lower case only no symbols or spaces to use\n",
    "\"\"\"\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "72e9d8c2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def _asdict_tool_call(tc: Any) -> dict:\n",
    "    try:\n",
    "        return {\n",
    "            \"id\": getattr(tc, \"id\", None),\n",
    "            \"type\": \"function\",\n",
    "            \"function\": {\n",
    "                \"name\": getattr(tc.function, \"name\", None),\n",
    "                \"arguments\": getattr(tc.function, \"arguments\", None),\n",
    "            },\n",
    "        }\n",
    "    except Exception:\n",
    "        return {\"type\": \"function\", \"function\": {\"name\": None, \"arguments\": None}}\n",
    "\n",
    "def _asdict_message(msg: Any) -> dict:\n",
    "    if isinstance(msg, dict):\n",
    "        return msg\n",
    "    role = getattr(msg, \"role\", None)\n",
    "    content = getattr(msg, \"content\", None)\n",
    "    tool_calls = getattr(msg, \"tool_calls\", None)\n",
    "    out = {\"role\": role, \"content\": content}\n",
    "    if tool_calls:\n",
    "        out[\"tool_calls\"] = [_asdict_tool_call(tc) for tc in tool_calls]\n",
    "    return out\n",
    "\n",
    "def _sanitized_messages_for_log(messages: list[dict | Any]) -> list[dict]:\n",
    "    return [_asdict_message(m) for m in messages]\n",
    "\n",
    "def _limit_text(s: str, limit: int = 40000) -> str:\n",
    "    return s if len(s) <= limit else (s[:limit] + \"\\n... [truncated]\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b45c6382",
   "metadata": {},
   "outputs": [],
   "source": [
    "def run_agent_with_tools(history: List[Dict]) -> Tuple[str, List[str], str]:\n",
    "    \"\"\"\n",
    "    Returns:\n",
    "      reply_md: final assistant markdown\n",
    "      tool_available: .coms marked available by RDAP tools (order-preserving, deduped)\n",
    "      dbg_text: concatenated log buffer (for the UI panel)\n",
    "    \"\"\"\n",
    "    messages: List[Dict] = [{\"role\": \"system\", \"content\": SYSTEM_PROMPT}] + history\n",
    "    tool_available: List[str] = []\n",
    "\n",
    "    dbg_json(_sanitized_messages_for_log(messages), \"=== LLM REQUEST (initial messages) ===\")\n",
    "    resp = openai.chat.completions.create(model=OPENAI_MODEL, messages=messages, tools=TOOLS)\n",
    "\n",
    "    while resp.choices[0].finish_reason == \"tool_calls\":\n",
    "        tool_msg_sdk = resp.choices[0].message\n",
    "        tool_msg = _asdict_message(tool_msg_sdk)\n",
    "        dbg_json(tool_msg, \"=== ASSISTANT (tool_calls) ===\")\n",
    "\n",
    "        tool_results = handle_tool_calls(tool_msg_sdk)\n",
    "\n",
    "        # Accumulate authoritative availability directly from tool outputs\n",
    "        for tr in tool_results:\n",
    "            try:\n",
    "                data = json.loads(tr[\"content\"])\n",
    "                if isinstance(data, dict) and isinstance(data.get(\"available\"), list):\n",
    "                    for d in data[\"available\"]:\n",
    "                        tool_available.append(_to_com(d))\n",
    "            except Exception:\n",
    "                pass\n",
    "\n",
    "        dbg_json([json.loads(tr[\"content\"]) for tr in tool_results], \"=== TOOL RESULTS ===\")\n",
    "\n",
    "        messages.append(tool_msg)\n",
    "        messages.extend(tool_results)\n",
    "        dbg_json(_sanitized_messages_for_log(messages), \"=== LLM REQUEST (messages + tools) ===\")\n",
    "\n",
    "        resp = openai.chat.completions.create(model=OPENAI_MODEL, messages=messages, tools=TOOLS)\n",
    "\n",
    "    # Dedup preserve order\n",
    "    seen, uniq = set(), []\n",
    "    for d in tool_available:\n",
    "        if d not in seen:\n",
    "            seen.add(d)\n",
    "            uniq.append(d)\n",
    "\n",
    "    reply_md = resp.choices[0].message.content\n",
    "    logger.debug(\"=== FINAL ASSISTANT ===\\n%s\", _limit_text(reply_md))\n",
    "    dbg_json(uniq, \"=== AVAILABLE FROM TOOLS (authoritative) ===\")\n",
    "\n",
    "    # Return current buffer text for the UI panel\n",
    "    dbg_text = _limit_text(get_log_text(), 40000)\n",
    "    return reply_md, uniq, dbg_text\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "92306515",
   "metadata": {},
   "outputs": [],
   "source": [
    "def extract_audio_text(markdown_reply: str) -> str:\n",
    "    \"\"\"\n",
    "    Pulls the 'Audio explanation:' section; falls back to first sentence.\n",
    "    \"\"\"\n",
    "    marker = \"Audio explanation:\"\n",
    "    lower = markdown_reply.lower()\n",
    "    idx = lower.find(marker.lower())\n",
    "    if idx != -1:\n",
    "        segment = markdown_reply[idx + len(marker):].strip()\n",
    "        parts = segment.split(\".\")\n",
    "        return (\". \".join([p.strip() for p in parts if p.strip()][:2]) + \".\").strip()\n",
    "    return \"This domain is the clearest, most memorable fit for the audience and brand goals.\"\n",
    "\n",
    "def synth_audio(text: str) -> bytes:\n",
    "    audio = openai.audio.speech.create(\n",
    "        model=TTS_MODEL,\n",
    "        voice=\"alloy\",\n",
    "        input=text\n",
    "    )\n",
    "    return audio.content\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cc6c0650",
   "metadata": {},
   "outputs": [],
   "source": [
    "\n",
    "_DOMAIN_RE = re.compile(r\"\\b[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.com\\b\", re.I)\n",
    "_HDR_AVAIL = re.compile(r\"^\\s*[\\d\\.\\)\\-]*\\s*available\\s+.*\\.com\\s+domains\", re.I)\n",
    "_HDR_PREF  = re.compile(r\"^\\s*[\\d\\.\\)\\-]*\\s*preferred\\s+domain\", re.I)\n",
    "\n",
    "def _norm_domain(s: str) -> str:\n",
    "    s = s.strip().lower()\n",
    "    return s if s.endswith(\".com\") else f\"{s}.com\"\n",
    "\n",
    "def parse_available(md: str) -> list[str]:\n",
    "    lines = md.splitlines()\n",
    "    out = []\n",
    "    in_section = False\n",
    "    for ln in lines:\n",
    "        if _HDR_AVAIL.search(ln):\n",
    "            in_section = True\n",
    "            continue\n",
    "        if in_section and _HDR_PREF.search(ln):\n",
    "            break\n",
    "        if in_section:\n",
    "            for m in _DOMAIN_RE.findall(ln):\n",
    "                out.append(_norm_domain(m))\n",
    "    # Fallback: if the header wasn't found, collect all .coms then we'll still\n",
    "    # rely on agent instruction to list only available, which should be safe.\n",
    "    if not out:\n",
    "        out = [_norm_domain(m) for m in _DOMAIN_RE.findall(md)]\n",
    "    # dedupe preserve order\n",
    "    seen, uniq = set(), []\n",
    "    for d in out:\n",
    "        if d not in seen:\n",
    "            seen.add(d)\n",
    "            uniq.append(d)\n",
    "    return uniq\n",
    "\n",
    "def parse_preferred(md: str) -> str:\n",
    "    # search the preferred section first\n",
    "    lines = md.splitlines()\n",
    "    start = None\n",
    "    for i, ln in enumerate(lines):\n",
    "        if _HDR_PREF.search(ln):\n",
    "            start = i\n",
    "            break\n",
    "    segment = \"\\n\".join(lines[start:start+8]) if start is not None else md[:500]\n",
    "    m = _DOMAIN_RE.search(segment)\n",
    "    if m:\n",
    "        return _norm_domain(m.group(0))\n",
    "    m = _DOMAIN_RE.search(md)\n",
    "    return _norm_domain(m.group(0)) if m else \"\"\n",
    "\n",
    "def merge_and_sort(old: list[str], new: list[str]) -> list[str]:\n",
    "    merged = {d.lower() for d in old} | {d.lower() for d in new}\n",
    "    return sorted(merged, key=lambda s: (len(s), s))\n",
    "\n",
    "def fmt_available_md(domains: list[str]) -> str:\n",
    "    if not domains:\n",
    "        return \"### Available .com domains (cumulative)\\n\\n*– none yet –*\"\n",
    "    items = \"\\n\".join(f\"- `{d}`\" for d in domains)\n",
    "    return f\"### Available .com domains (cumulative)\\n\\n{items}\"\n",
    "\n",
    "def fmt_preferred_md(d: str) -> str:\n",
    "    if not d:\n",
    "        return \"### Preferred domain\\n\\n*– not chosen yet –*\"\n",
    "    return f\"### Preferred domain\\n\\n`{d}`\"\n",
    "\n",
    "def build_context_msg(known_avail: Optional[List[str]], preferred_now: Optional[str]) -> str:\n",
    "    \"\"\"\n",
    "    Create a short 'state so far' block that we prepend to the next user turn\n",
    "    so the model always sees the preferred and cumulative available list.\n",
    "    \"\"\"\n",
    "    lines = []\n",
    "    if (preferred_now or \"\").strip():\n",
    "        lines.append(f\"Preferred domain so far: {preferred_now.strip().lower()}\")\n",
    "    if known_avail:\n",
    "        lines.append(\"Available .com domains discovered so far:\")\n",
    "        for d in known_avail:\n",
    "            if d:\n",
    "                lines.append(f\"- {d.strip().lower()}\")\n",
    "    if not lines:\n",
    "        return \"\"\n",
    "    return \"STATE TO CARRY OVER FROM PREVIOUS TURNS:\\n\" + \"\\n\".join(lines)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "07f079d6",
   "metadata": {},
   "outputs": [],
   "source": [
    "def run_and_extract(history: List[Dict]) -> Tuple[str, List[str], str, str, str]:\n",
    "    reply_md, avail_from_tools, dbg_text = run_agent_with_tools(history)\n",
    "    parsed_avail = parse_available(reply_md)\n",
    "    new_avail = merge_and_sort(avail_from_tools, parsed_avail)\n",
    "    preferred = parse_preferred(reply_md)\n",
    "    audio_text = extract_audio_text(reply_md)\n",
    "    return reply_md, new_avail, preferred, audio_text, dbg_text\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4cd5d8ef",
   "metadata": {},
   "outputs": [],
   "source": [
    "def initial_submit(industry: str, customers: str, desc: str,\n",
    "                   history: List[Dict], known_avail: List[str], preferred_now: str):\n",
    "    if CLEAR_LOG_ON_RUN:\n",
    "        clear_log_buffer()\n",
    "\n",
    "    logger.info(\"Initial submit | industry=%r | customers=%r | desc_len=%d\",\n",
    "                industry, customers, len(desc or \"\"))\n",
    "\n",
    "    # Build context (usually empty on the very first run, but future inits also work)\n",
    "    ctx = build_context_msg(known_avail or [], preferred_now or \"\")\n",
    "\n",
    "    user_msg = (\n",
    "        \"Please propose .com domains based on:\\n\"\n",
    "        f\"Industry: {industry}\\n\"\n",
    "        f\"Target Customers: {customers}\\n\"\n",
    "        f\"Description: {desc}\"\n",
    "    )\n",
    "\n",
    "    # Single user turn that includes state + prompt so the model always sees memory\n",
    "    full_content = (ctx + \"\\n\\n\" if ctx else \"\") + user_msg\n",
    "\n",
    "    history = (history or []) + [{\"role\": \"user\", \"content\": full_content}]\n",
    "    reply_md, new_avail, preferred, audio_text, dbg_text = run_and_extract(history)\n",
    "    history += [{\"role\": \"assistant\", \"content\": reply_md}]\n",
    "\n",
    "    all_avail = merge_and_sort(known_avail or [], new_avail or [])\n",
    "    preferred_final = preferred or preferred_now or \"\"\n",
    "    audio_bytes = synth_audio(audio_text)\n",
    "\n",
    "    return (\n",
    "        history,                         # s_history\n",
    "        all_avail,                       # s_available (cumulative)\n",
    "        preferred_final,                 # s_preferred\n",
    "        gr.update(value=fmt_preferred_md(preferred_final)),\n",
    "        gr.update(value=fmt_available_md(all_avail)),\n",
    "        gr.update(value=\"\", visible=True),                 # reply_in: show after first run\n",
    "        gr.update(value=audio_bytes, visible=True),        # audio_out\n",
    "        gr.update(value=dbg_text),                         # debug_box\n",
    "        gr.update(value=\"Find Domains (done)\", interactive=False),  # NEW: disable Find\n",
    "        gr.update(visible=True),                           # NEW: show Send button\n",
    "    )\n",
    "\n",
    "def refine_submit(reply: str,\n",
    "                  history: List[Dict], known_avail: List[str], preferred_now: str):\n",
    "    # If empty, do nothing (keeps UI state untouched)\n",
    "    if not (reply or \"\").strip():\n",
    "        return (\"\", history, known_avail, preferred_now,\n",
    "                gr.update(), gr.update(), gr.update(), gr.update())\n",
    "\n",
    "    if CLEAR_LOG_ON_RUN:\n",
    "        clear_log_buffer()\n",
    "    logger.info(\"Refine submit | user_reply_len=%d\", len(reply))\n",
    "\n",
    "    # Always prepend memory + the user's refinement so the model can iterate properly\n",
    "    ctx = build_context_msg(known_avail or [], preferred_now or \"\")\n",
    "    full_content = (ctx + \"\\n\\n\" if ctx else \"\") + reply.strip()\n",
    "\n",
    "    history = (history or []) + [{\"role\": \"user\", \"content\": full_content}]\n",
    "    reply_md, new_avail, preferred, audio_text, dbg_text = run_and_extract(history)\n",
    "    history += [{\"role\": \"assistant\", \"content\": reply_md}]\n",
    "\n",
    "    all_avail = merge_and_sort(known_avail or [], new_avail or [])\n",
    "    preferred_final = preferred or preferred_now or \"\"\n",
    "    audio_bytes = synth_audio(audio_text)\n",
    "\n",
    "    return (\n",
    "        \"\",                                 # clear Reply box\n",
    "        history,                            # s_history\n",
    "        all_avail,                          # s_available (cumulative)\n",
    "        preferred_final,                    # s_preferred\n",
    "        gr.update(value=fmt_preferred_md(preferred_final)),\n",
    "        gr.update(value=fmt_available_md(all_avail)),\n",
    "        gr.update(value=audio_bytes, visible=True),\n",
    "        gr.update(value=dbg_text),          # debug_box\n",
    "    )\n",
    "\n",
    "def clear_debug():\n",
    "    clear_log_buffer()\n",
    "    return gr.update(value=\"\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d52ebc02",
   "metadata": {},
   "outputs": [],
   "source": [
    "with gr.Blocks(title=\"AI Domain Finder (.com only)\") as ui:\n",
    "    gr.Markdown(\"# AI Domain Finder (.com only)\")\n",
    "    gr.Markdown(\"Agent proposes .com domains, verifies via RDAP, picks a preferred choice, and explains briefly.\")\n",
    "\n",
    "    # App state\n",
    "    s_history = gr.State([])\n",
    "    s_available = gr.State([])\n",
    "    s_preferred = gr.State(\"\")\n",
    "\n",
    "    with gr.Row():\n",
    "        with gr.Column(scale=7):  # LEFT 70%\n",
    "            with gr.Group():\n",
    "                industry_in = gr.Textbox(label=\"Industry\")\n",
    "                customers_in = gr.Textbox(label=\"Target Customers\")\n",
    "                desc_in = gr.Textbox(label=\"Description\", lines=3)\n",
    "                find_btn = gr.Button(\"Find Domains\", variant=\"primary\")\n",
    "\n",
    "            audio_out = gr.Audio(label=\"Audio explanation\", autoplay=True, visible=False)\n",
    "\n",
    "            with gr.Row():\n",
    "                reply_in = gr.Textbox(\n",
    "                    label=\"Reply\",\n",
    "                    placeholder=\"Chat with the agent to refine the outputs\",\n",
    "                    lines=2,\n",
    "                    visible=False,  # hidden for the first input\n",
    "                )\n",
    "                send_btn = gr.Button(\"Send\", variant=\"primary\", visible=False)\n",
    "\n",
    "        with gr.Column(scale=3):  # RIGHT 30%\n",
    "            preferred_md = gr.Markdown(fmt_preferred_md(\"\"))\n",
    "            available_md = gr.Markdown(fmt_available_md([]))\n",
    "\n",
    "            with gr.Accordion(\"Debug log\", open=False):\n",
    "                debug_box = gr.Textbox(label=\"Log\", value=\"\", lines=16, interactive=False)\n",
    "                clear_btn = gr.Button(\"Clear log\", size=\"sm\")\n",
    "\n",
    "    # Events\n",
    "    # Initial run: also disables Find and shows Send\n",
    "    find_btn.click(\n",
    "        initial_submit,\n",
    "        inputs=[industry_in, customers_in, desc_in, s_history, s_available, s_preferred],\n",
    "        outputs=[\n",
    "            s_history, s_available, s_preferred,\n",
    "            preferred_md, available_md,\n",
    "            reply_in,            # visible after first run\n",
    "            audio_out,           # visible after first run\n",
    "            debug_box,\n",
    "            find_btn,            # NEW: disable + relabel\n",
    "            send_btn,            # NEW: show the Send button\n",
    "        ],\n",
    "    )\n",
    "\n",
    "    # Multi-turn submit via Enter in the textbox\n",
    "    reply_in.submit(\n",
    "        refine_submit,\n",
    "        inputs=[reply_in, s_history, s_available, s_preferred],\n",
    "        outputs=[\n",
    "            reply_in, s_history, s_available, s_preferred,\n",
    "            preferred_md, available_md, audio_out, debug_box\n",
    "        ],\n",
    "    )\n",
    "\n",
    "    # Multi-turn submit via explicit Send button\n",
    "    send_btn.click(\n",
    "        refine_submit,\n",
    "        inputs=[reply_in, s_history, s_available, s_preferred],\n",
    "        outputs=[\n",
    "            reply_in, s_history, s_available, s_preferred,\n",
    "            preferred_md, available_md, audio_out, debug_box\n",
    "        ],\n",
    "    )\n",
    "\n",
    "    clear_btn.click(clear_debug, inputs=[], outputs=[debug_box])\n",
    "\n",
    "ui.launch(inbrowser=True, show_error=True)\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "llm-engineering",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
